Skip to content

Use Merkl for market and vault rewards#575

Merged
antoncoding merged 3 commits into
masterfrom
fix/merkl-market-rewards
Jun 12, 2026
Merged

Use Merkl for market and vault rewards#575
antoncoding merged 3 commits into
masterfrom
fix/merkl-market-rewards

Conversation

@antoncoding

@antoncoding antoncoding commented Jun 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • keep market reward campaign display Merkl-backed via /v4/campaigns?withOpportunity=true through the existing /api/merkl proxy
  • move Vault V2 reward APR display to direct Merkl /v4/opportunities?mainProtocolId=morpho&explorerAddress={vault}&campaigns=true lookups, removing the Morpho vaultV2ByAddress.rewards dependency
  • update user reward claiming to Merkl /v4/users/{address}/rewards/summary and keep claim/proof data behind the server-side /api/merkl proxy with X-API-Key
  • preserve Merkl user claiming, but remove the legacy MORPHO wrapper/non-Merkl reward surface from /rewards/:account
  • keep single-token Merkl campaigns attached only through the existing whitelisted loan-token market path
  • address review feedback for shared borrow-campaign classification, supported-chain narrowing, campaign pagination guardrails, and defensive Merkl params handling
  • document the Merkl market/vault/user reward validation rules

Live checks

  • Current live Merkl campaign type probes for Morpho return MORPHOSUPPLY, MORPHOBORROW, MORPHOSUPPLY_SINGLETOKEN, and MULTILENDBORROW for market campaign display; live Merkl opportunities also include vault/collateral types such as ERC20LOGPROCESSOR, MORPHOVAULT, and MORPHOCOLLATERAL
  • JPYR market 0x1f719d50287d50d75ef7f84a430a5168d0b2f8591debbac404f522687876cd52: Merkl returns a live MORPHOSUPPLY campaign with 15% APR paid in JPYR
  • USDC/USD3 market 0xe3df58f9d3011b7481ff36b939fa5f8da642f34ea5792d25d3958dbf1efa26d7: Merkl returns no live market-level campaign across the market campaign types the hook fetches
  • 3Jane vault 0xe05faDf242331808f504661BEA65972594869826: Merkl returns a live ERC20LOGPROCESSOR LEND opportunity with roughly 7.6% APR paid in USDC, targeting the vault token
  • local /api/merkl proxy routes returned 200 for campaign, vault opportunity, and /rewards/summary checks; the route forwards MERKL_API_KEY as X-API-Key when configured
  • local vault route returned 200: /vault/1/0xe05faDf242331808f504661BEA65972594869826

Validation

  • npx ultracite fix
  • npx ultracite check
  • pnpm typecheck
  • pnpm lint:check
  • git diff --check

Summary by CodeRabbit

  • Bug Fixes

    • Refined reward APR calculations to accurately filter campaigns by selected mode (supply/borrow)
  • Documentation

    • Updated technical documentation describing market reward campaign sourcing and validation rules
    • Clarified Merkl API integration details and configured HOLD opportunity lookups
  • Refactor

    • Improved campaign filtering logic and Merkl campaign processing
    • Streamlined reward display computation
  • Revert

    • Removed legacy MORPHO token wrapping feature and related UI elements

@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR consolidates reward sourcing around Merkl by adding a new Merkl vault V2 rewards data source, refactoring the campaign validation pipeline, removing legacy MORPHO wrapping, and filtering market campaigns by supply/borrow mode and chain.

Changes

Merkl Reward Source Consolidation & Legacy MORPHO Removal

Layer / File(s) Summary
Type System Updates
src/utils/merklTypes.ts
Introduces MarketRewardType union and updates MerklCampaignParams to loosen field optionality; aligns MerklCampaign, MerklApiParams, and SimplifiedCampaign types to use MarketRewardType.
Merkl API Validation & Pagination
src/utils/merklApi.ts
Refactors pagination from hasMore flag to infinite loop; tightens active campaign detection to require finite positive APR; updates simplifyMerklCampaign to return nullable and skip missing identifiers; changes expandMultiLendBorrowCampaign to flatMap markets.
Merkl Vault V2 Rewards Data Source
src/data-sources/merkl/vault-rewards.ts, src/hooks/queries/useVaultV2RewardsQuery.ts
New module fetches Merkl opportunities by vault, filters to active lend opportunities, aggregates campaign rewards by token and chain. Hook switched from Morpho to Merkl data source.
Morpho V2 Rewards & Legacy Wrapper Removal
src/data-sources/morpho-api/vaults.ts, src/graphql/vault-queries.ts, src/utils/tokens.ts
Removes fetchMorphoVaultV2Rewards, vault reward types, GraphQL query, wrapper ABI, and MORPHO_TOKEN_WRAPPER constant; exports underlying token addresses.
Campaign Query Simplification Helper
src/hooks/queries/useMerklCampaignsQuery.ts
Centralizes campaign typing and expansion in toSimplifiedCampaigns helper; refactors queryFn to directly convert settled promises to simplified campaigns.
Campaign Filtering & Mode Classification
src/hooks/useMarketCampaigns.ts, src/features/markets/components/rewards-indicator.tsx
Updates useMarketCampaigns to filter by both chainId and normalized marketId; expands borrower classification to check opportunityAction === 'BORROW' in addition to type.
Mode-Filtered Campaign Rendering in Market Details
src/features/markets/components/market-details-block.tsx
Adds memoized modeCampaigns filtering by selected mode; updates "Extra Rewards" section to conditionally render and use mode-filtered campaigns; switches keys to campaign.campaignId.
Rewards View Cleanup & Legacy Balance Removal
src/features/rewards/rewards-view.tsx
Removes legacy MORPHO balance display and wrap UI; updates refresh handler to skip legacy refetch; adjusts totalClaimable to sum only mainnet/base MORPHO addresses.
User Rewards Endpoint & Chain Filtering
src/hooks/queries/useUserRewardsQuery.ts
Switches to /v4/users/{address}/rewards/summary endpoint; adds guard to skip unsupported chains.
Documentation & Validation Rules
docs/TECHNICAL_OVERVIEW.md, docs/VALIDATIONS.md
Updates technical overview to document Merkl as reward source with server-side auth; adds validation rules specifying Merkl as canonical source for market campaigns, vault APR, and user claims.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • antoncoding/monarch#238: Switches user/Vault V2 rewards data fetching and claiming UI to consume Merkl rewards-with-proofs.
  • antoncoding/monarch#290: Updates Merkl campaign typing, transformation logic, and borrower/supplier labeling in parallel.
  • antoncoding/monarch#558: Adds server-side /api/merkl proxy route that enables this PR's Merkl API fetching.

Suggested labels

feature request

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: migrating market and vault reward data sources from Morpho API to Merkl API.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/merkl-market-rewards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
monarch Ready Ready Preview, Comment Jun 12, 2026 3:11am

@coderabbitai coderabbitai Bot added the bug Something isn't working label Jun 11, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request removes legacy Morpho wrapping logic, updates technical documentation, and refactors Merkl campaign querying and filtering. Feedback on the changes highlights a classification bug in campaign-badge.tsx and market-details-block.tsx where MULTILENDBORROW campaigns with a borrow action are incorrectly grouped under supply rewards. Additionally, defensive checks are recommended in merklApi.ts to prevent potential runtime crashes when accessing campaign.params.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +31 to +33
return activeCampaigns.filter((campaign) =>
filterType === 'borrow' ? campaign.type === 'MORPHOBORROW' : campaign.type !== 'MORPHOBORROW',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is an inconsistency in how borrow/supply campaigns are filtered. If filterType === 'borrow', it only checks campaign.type === 'MORPHOBORROW'. This misses MULTILENDBORROW campaigns that are actually borrow campaigns (where campaign.opportunityAction === 'BORROW'). Conversely, if filterType === 'supply', it incorrectly includes those borrow-action MULTILENDBORROW campaigns because their type is not MORPHOBORROW.

We should use a consistent check that accounts for both the campaign type and the opportunity action.

    return activeCampaigns.filter((campaign) => {
      const isBorrow = campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW';
      return filterType === 'borrow' ? isBorrow : !isBorrow;
    });

Comment on lines +55 to +57
activeCampaigns.filter((campaign) =>
mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW',
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This filter has the same classification bug as the campaign badge. When mode === 'supply', it uses campaign.type !== 'MORPHOBORROW', which incorrectly includes MULTILENDBORROW campaigns that have opportunityAction === 'BORROW'. This causes those campaigns to be counted as both supply and borrow rewards.

We should use a consistent check that accounts for both the campaign type and the opportunity action.

Suggested change
activeCampaigns.filter((campaign) =>
mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW',
),
activeCampaigns.filter((campaign) => {
const isBorrow = campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW';
return mode === 'borrow' ? isBorrow : !isBorrow;
}),

Comment thread src/utils/merklApi.ts Outdated
Comment on lines 195 to 196
export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null {
const baseFields = getBaseCampaignFields(campaign);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since campaign.params comes from an external API and its properties are marked as optional in MerklCampaignParams, we should defensively check if campaign.params itself is defined before accessing its properties to prevent potential runtime crashes (e.g., TypeError: Cannot read properties of undefined).

Suggested change
export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null {
const baseFields = getBaseCampaignFields(campaign);
export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null {
if (!campaign.params) return null;
const baseFields = getBaseCampaignFields(campaign);

Comment thread src/utils/merklApi.ts Outdated
Comment on lines 223 to 224
export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] {
const baseFields = getBaseCampaignFields(campaign);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, we should defensively check if campaign.params is defined before accessing campaign.params.markets to prevent runtime crashes if the external API returns a response without params.

Suggested change
export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] {
const baseFields = getBaseCampaignFields(campaign);
export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] {
if (!campaign.params?.markets) return [];
const baseFields = getBaseCampaignFields(campaign);

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/utils/merklApi.ts (1)

84-104: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate page size before entering the infinite loop.

Line 84 accepts params.items as-is. If it is 0 or negative, the break check at Line 99 can never succeed, so the loop at Line 88 can run forever.

Suggested fix
 export async function fetchActiveCampaigns(params: Omit<MerklApiParams, 'startTimestamp' | 'endTimestamp'> = {}): Promise<MerklCampaign[]> {
   const now = Math.floor(Date.now() / 1000);
-  const pageSize = params.items ?? 100;
+  const requestedPageSize = params.items ?? 100;
+  const pageSize = Number.isInteger(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 100;
   const allCampaigns: MerklCampaign[] = [];
   let currentPage = 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/merklApi.ts` around lines 84 - 104, The loop can become infinite if
params.items is 0 or negative; validate and normalize pageSize before entering
the while loop by deriving pageSize from params.items and ensuring it's a
positive integer (e.g., default to 100 or clamp to a minimum of 1) so that the
batch.length < pageSize break condition can eventually succeed; update the logic
around the pageSize assignment (used with fetchCampaigns, currentPage, and
allCampaigns) to either throw a clear validation error for non-positive values
or set a safe default/minimum.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/markets/components/market-details-block.tsx`:
- Around line 53-57: Replace the inline borrow-detection logic inside the
modeCampaigns useMemo with a shared helper: create and export an
isBorrowCampaign(campaign) utility that returns true when campaign.type ===
'MORPHOBORROW' || campaign.opportunityAction === 'BORROW', import that helper
into this component, and update the useMemo filter to use
isBorrowCampaign(campaign) for the borrow branch (and its negation for the
non-borrow branch) so modeCampaigns uses the centralized logic.

---

Outside diff comments:
In `@src/utils/merklApi.ts`:
- Around line 84-104: The loop can become infinite if params.items is 0 or
negative; validate and normalize pageSize before entering the while loop by
deriving pageSize from params.items and ensuring it's a positive integer (e.g.,
default to 100 or clamp to a minimum of 1) so that the batch.length < pageSize
break condition can eventually succeed; update the logic around the pageSize
assignment (used with fetchCampaigns, currentPage, and allCampaigns) to either
throw a clear validation error for non-positive values or set a safe
default/minimum.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 320d5eca-db5f-4b78-8dca-39014cf82f55

📥 Commits

Reviewing files that changed from the base of the PR and between 2dfb5f2 and bd06d2b.

📒 Files selected for processing (15)
  • docs/TECHNICAL_OVERVIEW.md
  • docs/VALIDATIONS.md
  • src/abis/morpho-wrapper.ts
  • src/features/market-detail/components/campaign-badge.tsx
  • src/features/market-detail/components/campaign-modal.tsx
  • src/features/markets/components/apy-breakdown-tooltip.tsx
  • src/features/markets/components/market-details-block.tsx
  • src/features/markets/components/rewards-indicator.tsx
  • src/features/rewards/rewards-view.tsx
  • src/hooks/queries/useMerklCampaignsQuery.ts
  • src/hooks/useMarketCampaigns.ts
  • src/hooks/useWrapLegacyMorpho.ts
  • src/utils/merklApi.ts
  • src/utils/merklTypes.ts
  • src/utils/tokens.ts
💤 Files with no reviewable changes (3)
  • src/hooks/useWrapLegacyMorpho.ts
  • src/abis/morpho-wrapper.ts
  • src/utils/tokens.ts

Comment thread src/features/markets/components/market-details-block.tsx Outdated
@antoncoding antoncoding force-pushed the fix/merkl-market-rewards branch from d249c55 to 6df0eb2 Compare June 12, 2026 02:37
@coderabbitai coderabbitai Bot added the feature request Specific feature ready to be implemented label Jun 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/hooks/useMarketCampaigns.ts (1)

40-40: ⚡ Quick win

Unnecessary optional chaining on marketId.

campaign.marketId is non-optional in SimplifiedCampaign, so .toLowerCase() can be called directly.

♻️ Remove optional chaining
-    (campaign) => campaign.chainId === chainId && campaign.marketId?.toLowerCase() === normalizedMarketId,
+    (campaign) => campaign.chainId === chainId && campaign.marketId.toLowerCase() === normalizedMarketId,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useMarketCampaigns.ts` at line 40, The predicate in
useMarketCampaigns.ts unnecessarily uses optional chaining on campaign.marketId
even though SimplifiedCampaign defines marketId as non-optional; update the
filter predicate (the arrow function comparing chainId and marketId) to call
toLowerCase() directly on campaign.marketId (i.e., remove the `?`) so it reads
campaign.chainId === chainId && campaign.marketId.toLowerCase() ===
normalizedMarketId, leaving the rest of the logic unchanged.
src/hooks/queries/useUserRewardsQuery.ts (1)

68-70: ⚡ Quick win

Use existing isSupportedNetwork type guard.

The project provides isSupportedNetwork(chainId) for chain ID validation. Using it here improves type safety and follows the established pattern.

♻️ Proposed refactor
-    if (!ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id)) {
+    if (!isSupportedNetwork(chainData.chain.id)) {
       continue;
     }

Add the import at the top:

-import { ALL_SUPPORTED_NETWORKS } from '`@/utils/networks`';
+import { ALL_SUPPORTED_NETWORKS, isSupportedNetwork } from '`@/utils/networks`';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/queries/useUserRewardsQuery.ts` around lines 68 - 70, Replace the
manual check using ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id) with the
project type guard isSupportedNetwork to get proper type narrowing; add an
import for isSupportedNetwork at the top of useUserRewardsQuery.ts and change
the condition to if (!isSupportedNetwork(chainData.chain.id)) continue; so
chainId is correctly type-guarded for downstream usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useMarketCampaigns.ts`:
- Line 40: The filter in useMarketCampaigns that builds directMarketCampaigns
uses campaign.chainId === chainId which yields no results when chainId is
undefined; either make chainId required on MarketCampaignsOptions (update the
MarketCampaignsOptions type/interface to make chainId non-optional and adjust
callsites) or explicitly handle undefined by changing the predicate in the
directMarketCampaigns filter inside useMarketCampaigns to a safe check (e.g.,
only compare when chainId is defined or fall back to comparing only marketId
when chainId is undefined) and ensure normalizedMarketId logic remains intact.

---

Nitpick comments:
In `@src/hooks/queries/useUserRewardsQuery.ts`:
- Around line 68-70: Replace the manual check using
ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id) with the project type guard
isSupportedNetwork to get proper type narrowing; add an import for
isSupportedNetwork at the top of useUserRewardsQuery.ts and change the condition
to if (!isSupportedNetwork(chainData.chain.id)) continue; so chainId is
correctly type-guarded for downstream usage.

In `@src/hooks/useMarketCampaigns.ts`:
- Line 40: The predicate in useMarketCampaigns.ts unnecessarily uses optional
chaining on campaign.marketId even though SimplifiedCampaign defines marketId as
non-optional; update the filter predicate (the arrow function comparing chainId
and marketId) to call toLowerCase() directly on campaign.marketId (i.e., remove
the `?`) so it reads campaign.chainId === chainId &&
campaign.marketId.toLowerCase() === normalizedMarketId, leaving the rest of the
logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 3d3f3400-187f-4a03-80cc-9a8f538eb15f

📥 Commits

Reviewing files that changed from the base of the PR and between bd06d2b and 6df0eb2.

📒 Files selected for processing (12)
  • docs/TECHNICAL_OVERVIEW.md
  • docs/VALIDATIONS.md
  • src/data-sources/merkl/vault-rewards.ts
  • src/data-sources/morpho-api/vaults.ts
  • src/features/markets/components/rewards-indicator.tsx
  • src/graphql/vault-queries.ts
  • src/hooks/queries/useMerklCampaignsQuery.ts
  • src/hooks/queries/useUserRewardsQuery.ts
  • src/hooks/queries/useVaultV2RewardsQuery.ts
  • src/hooks/useMarketCampaigns.ts
  • src/utils/merklApi.ts
  • src/utils/merklTypes.ts
💤 Files with no reviewable changes (1)
  • src/graphql/vault-queries.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/TECHNICAL_OVERVIEW.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/hooks/queries/useMerklCampaignsQuery.ts
  • src/utils/merklApi.ts

Comment thread src/hooks/useMarketCampaigns.ts Outdated
@antoncoding antoncoding changed the title Fix Merkl market rewards Use Merkl for market and vault rewards Jun 12, 2026
@antoncoding antoncoding merged commit c04a91b into master Jun 12, 2026
4 checks passed
@antoncoding antoncoding deleted the fix/merkl-market-rewards branch June 12, 2026 05:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working feature request Specific feature ready to be implemented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant